[표 1] 13주차 수업에 사용된 주요 함수 목록
함수 패키지 역할 주요 논항 및 예시
array() Base R 딥러닝 입력용 다차원 배열(텐서)을 초기화함(원핫 인코딩용) array(0L, dim = c(samples, timesteps, features))
layer_input() keras3 모델의 ’입구’를 정의함(데이터의 형태 shape 지정) layer_input(shape = c(NA, num_encoder_tokens))
layer_lstm() keras3 LSTM 레이어를 정의함(기억용량 설정, 마지막 타입스텝의 출력+상태 둘 다 반환 여부 설정, 마지막 타입스텝의 출력+모든 타임스텝의 은닉상태 반환 여부 설정) layer_lstm(units = 256, return_state = TRUE, return_sequences = TRUE)
layer_dense() keras3 뉴런과 활성화 함수(softmax 등)를 연결하는 출력층을 정의함 layer_dense(units = vocab_size, activation = “softmax”)
keras_model() keras3 입력과 출력을 연결하여 전체 모델을 조립함 keras_model(inputs = c(enc_in, dec_in), outputs = dec_out)
compile() keras3 모델의 학습 방식(옵티마이저, 손실함수)을 설정함 compile(optimizer = “rmsprop”, loss = “categorical_crossentropy”)
fit() keras3 준비된 데이터로 모델을 실제로 학습시킴 fit(x = list(input1, input2), y = target, epochs = 20)
predict() keras3 학습된 모델을 사용해 결과를 예측함 predict(input_data, verbose = 0)
str_split() tidyverse(stringr) 문자열을 글자 단위 등으로 쪼갬 str_split(text, ““)

1. RNN, LSTM, Seq2Seq의 텍스트 생성 원리

(1) 순차 데이터와 ‘기억’: RNN과 LSTM

1) 문제 제기: 왜 전통적인 신경망은 텍스트에 약한가?

  • 표준적인 신경망(MLP)이나 이미지 처리에 강력한 CNN(합성곱 신경망)은 텍스트 데이터를 다루는 데 근본적인 한계가 있음.
  • 입력 크기의 비고정성
    • MLP나 CNN은 고정된 크기의 입력([EX] 784개 픽셀, 100차원 임베딩 벡터)을 가정함.
    • 하지만 문장은 “안녕”(2글자)처럼 짧을 수도, “나는 오늘…”(100글자)처럼 길 수도 있음.
  • ‘순서’ 정보의 상실
    • 표준 신경망은 입력 데이터의 ’순서’를 고려하지 않음.
    • “나는 너를 좋아해”와 “너는 나를 좋아해”는 사용된 단어는 같지만, 순서가 달라 의미가 완전히 반대임. 표준 신경망은 이 차이를 감지하기 어려움.
  • ‘기억’ 또는 ’문맥’의 부재
    • “나는 오늘 점심으로 햄버거를 먹었다. 그래서 지금…” 다음에 올 말을 예측하려면, 최소한 “햄버거를 먹었다”는 사실을 ’기억’하고 있어야 “배가 부르다”라고 예측할 수 있음.
    • 표준 신경망은 이러한 ’문맥’을 기억하는 메커니즘이 없음.
  • 이처럼 ’순서’가 있고, 이전의 정보가 다음 정보에 영향을 주는 데이터시퀀스(sequence) 데이터라고 부름. 텍스트나 음성신호가 모두 이에 해당함.

2) RNN의 원리: “기억의 고리(loop)”

A. RNN의 원리와 작동방식

  • 이런 시퀀스 데이터를 처리하기 위해 고안된 것이 순환 신경망(recurrent neural network, RNN)임.
  • RNN의 핵심은 ’반복’과 ’기억’임. 네트워크에 루프(loop)가 달려있어, 이전 단계의 정보가 현재 단계의 입력으로 다시 들어옴.
  • 이 루프를 시간 순서대로 펼쳐보면(unroll) 다음과 같음.
    • \(x_t\)(입력): 현재 시점(time step \(t\))의 단어([EX] “학교에”).
    • \(h_t\)(은닉상태): 현재 시점의 ‘기억’. 이 기억은 2가지 정보를 바탕으로 만들어짐.
      1. 현재 입력(\(x_t\)).
      2. 이전 시점의 기억(\(h_{t-1}\))([EX] “나는 오늘”까지 읽은 내용).
    • \(y_t\)(출력): 현재 시점의 예측값([EX] “갔다”).
  • 작동방식(문장 읽기 비유)
    1. \(t=1\): “나는”(\(x_1\))을 읽음(이전 기억 \(h_0\)는 0벡터). “나는”에 대한 기억(\(h_1\))을 만듦.
    2. \(t=2\): “오늘”(\(x_2\))을 읽음. 이때 이전 기억(\(h_1\))을 함께 참고함. “나는 오늘”까지의 기억(\(h_2\))을 새로 만듦.
    3. \(t=3\): “학교에”(\(x_3\))를 읽음. 이전 기억(\(h_2\))을 참고함. “나는 오늘 학교에”까지의 기억(\(h_3\))을 만듦.
    4. 이 기억(\(h_3\))을 바탕으로 다음 단어 “갔다”(\(y_3\))를 예측함.
  • 모든 시점에서 동일한 가중치(파라미터)를 공유하기 때문에, 문장이 길어져도 모델의 파라미터 수가 늘어나지 않음.
    • 가중치 공유란?
      1. 문제상황: 문장의 길이는 제각각이다
      • 우리가 다루는 언어 데이터는 길이가 다 다름. “안녕?”은 두 글자지만, “오늘 점심에 뭐 먹었어?”는 훨씬 김.
      • 만약 우리가 문장을 처리하는 모델을 만든다고 가정해보자.
      • 상황 A: 가중치를 공유하지 않는 경우(비효율적인 방법)
        • 요약: 문장의 첫 번째 단어를 처리하는 ‘전문가 A’, 두 번째 단어를 처리하는 ‘전문가 B’, 세 번째를 처리하는 ‘전문가 C’… 이렇게 단어 위치마다 다른 전문가를 고용하는 것과 같음.
        • 예시
          • 3단어 문장이 오면? \(\Rightarrow\) 전문가 3명이 필요함.
          • 10단어 문장이 오면? \(\Rightarrow\) 전문가 10명이 필요함.
          • 100단어 문장이 오면? \(\Rightarrow\) 전문가 100명이 필요함.
      1. 해결책: RNN의 가중치 공유(똑똑한 한 명의 전문가)
      • RNN은 이 문제를 아주 똑똑하게 해결함. 바로 가중치 공유라는 아이디어. \(\Rightarrow\) 문장을 처리할 때 여러 명의 전문가를 쓰는 게 아니라, “아주 똑똑한 번역가 한 명”을 고용하는 것과 같음.
      • 상황 B: RNN의 방식(효율적인 방법)
        • 요약: ‘김RNN’ 씨라는 번역가 한 명이 있음. 이 번역가의 뇌 속에 있는 지식과 경험이 바로 가중치(파라미터)임.
        • 과정
          1. 첫 번째 시점(단어 1): ‘김RNN’ 씨가 첫 번째 단어를 보고 자신의 뇌(가중치)를 사용해 해석함. 그리고 그 내용을 머릿속 어딘가에 ’기억(은닉상태)’해 둠.
          2. 두 번째 시점(단어 2): 두 번째 단어가 들어옴. 이때 새로운 번역가를 부르는 게 아님! 아까 그 ‘김RNN’ 씨가 똑같은 뇌(동일한 가중치)를 사용해서 두 번째 단어를 해석함. 단, 이때는 아까 기억해둔 첫 번째 단어의 내용까지 같이 참고해서 해석함.
          3. 세 번째 시점(단어 3): 세 번째 단어가 들어와도 마찬가지임. 여전히 ‘김RNN’ 씨가 똑같은 뇌(동일한 가중치)로 해석을 수행함.
    • 핵심요약
      • “모든 시점에서 동일한 가중치를 공유한다.” \(\Rightarrow\) “문장의 몇 번째 단어를 처리하든, 그것을 처리하는 ’모델의 두뇌(계산공식)’는 똑같은 것을 재사용한다.”
      • 따라서 문장이 아무리 길어져도 새로운 두뇌를 만들 필요가 없음. 그냥 기존에 있던 그 두뇌 하나가 부지런히 첫 번째 단어부터 마지막 단어까지 순서대로 처리하면 되기 때문임.

[그림 1] RNN의 원리와 작동방식

B. 은닉상태(hidden state)(\(h_t\))란 무엇인가?

  • 은닉상태(\(h_t\))는 RNN의 메모리임.
  • 이것은 단순히 값이 하나인 변수가 아니라, 수백 개의 숫자로 이루어진 ‘벡터(vector)’임(우리 실습에서는 latent_dim = 256으로 설정, 즉 256개의 숫자 꾸러미임).
  • 이 256개의 숫자 안에는 \(t\) 시점까지 읽어들인 시퀀스의 모든 핵심 정보(문법, 의미, 문맥)가 압축되어 담겨 있음.
  • \(h_t = \tanh(W_{hh} \cdot h_{t-1} + W_{xh} \cdot x_t + b)\): 이전 기억(\(h_{t-1}\))과 현재 입력(\(x_t\))을 각각 가중치(W)와 곱한 뒤 더해서, 새로운 기억(\(h_t\))으로 갱신한다는 의미임.
    • tanh의 역할: 수치 압축기
      • RNN 셀 내부 상황: 계속해서 숫자들을 곱하고 더하는 계산이 일어남. 그러다 보면 계산결과가 엄청나게 커질 수도 있고, 반대로 엄청나게 작아질 수도 있음.
      • tanh은 이렇게 계산된 값을 입력으로 받아서 무조건 -1과 1 사이의 값으로 ‘꾹 눌러서’ 변환해주는 함수임.
        • 만약 계산결과가 100처럼 아주 큰 숫자가 나왔다? \(\Rightarrow\) tanh을 통과하면 1에 가까운 숫자가 됨.
        • 만약 -50처럼 아주 작은 숫자가 나왔다? \(\Rightarrow\) tanh을 통과하면 -1에 가까운 숫자가 됨.
        • 0이 들어가면? \(\Rightarrow\) 똑같이 0이 나옴.
        • 요약: 부피가 큰 솜뭉치를 작은 상자에 꾹꾹 눌러 담는 것처럼, 값의 범위를 딱 정해진 구간(-1~1) 안으로 가둬두는 역할을 함.
      • tanh의 필요성: 안전장치
        • 질문: 왜 굳이 값을 압축해야 할까? 그냥 쓰면 안 될까?
        • 답변
          • 숫자 폭발 방지: RNN은 이전 단계의 계산결과를 다음 단계로 계속 넘겨주면서 더하고 곱하는 구조임. 만약 tanh 같은 압축기가 없다면, 숫자가 눈덩이처럼 불어나서 나중에는 컴퓨터가 감당할 수 없을 만큼 커져버릴 수 있음. 이걸 ’기울기 폭발(gradient exploding)’이라고 하는데, tanh이 이걸 막아주는 안전장치 역할을 함.
          • 복잡한 문제 해결 능력 부여(비선형성)
            • 단순히 숫자만 더하고 곱하는 건 ‘직선’으로 된 문제밖에 못 품. 그런데 현실세계의 언어 문제는 아주 복잡하게 꼬여있는 ’곡선’ 같은 문제임.
            • tanh은 이렇게 직선적인 계산결과를 S자 곡선 형태로 살짝 구부려주는 역할을 함. 덕분에 모델이 단순한 문제뿐만 아니라 복잡하고 미묘한 언어의 패턴까지도 학습할 수 있게 되는 것.
# 1. 라이브러리 로드
library(tidyverse)

# 2. 데이터 준비 (데이터 프레임 형태)
x_values <- seq(from = -5, to = 5, by = 0.1)
data_for_plot <- tibble(
  x = x_values,
  y = tanh(x_values)
)

# 3. ggplot2로 그래프 그리기

tanh_plot <- ggplot(data = data_for_plot, aes(x = x, y = y)) +
  # (1) 메인 그래프: 파란색 선으로 tanh 함수 그리기
  geom_line(color = "blue", size = 1.2) +

  # (2) 기준선 추가: 상한선(y=1), 하한선(y=-1), 중앙선(y=0)
  geom_hline(yintercept = 1, color = "red", linetype = "dashed", size = 0.8) + # 상한선
  geom_hline(yintercept = -1, color = "red", linetype = "dashed", size = 0.8) + # 하한선
  geom_hline(yintercept = 0, color = "gray", linetype = "solid", size = 0.5) + # 중앙선

  # (3) 라벨 및 제목 설정
  labs(title = "ggplot2로 그린 tanh(쌍곡 탄젠트) 함수",
       subtitle = "어떤 입력값이 와도 -1과 1 사이로 압축",
       x = "입력값(계산결과)",
       y = "출력값(압축된 값)") +

  # (4) 테마 및 축 범위 설정
  theme_minimal() + # 깔끔한 테마 적용
  coord_cartesian(ylim = c(-1.5, 1.5)) + # y축 범위를 넓게 잡아서 기준선이 잘 보이게 함

  # (5) 그래프 중앙에 텍스트 주석 추가 (선택 사항)
  annotate("text", x = 0, y = 0.2, label = "기울기 폭발 방지 구간", color = "darkgray", fontface = "italic")
tanh_plot

  • RNN의 한계
    • 정의: 이 갱신과정은 덮어쓰기에 가까워서, \(h_1\)의 정보가 \(h_2\), \(h_3\)로 넘어가면서 점점 희미해짐.
    • 비유: “딱 한 장뿐인 메모지”
      • 설명: 아주 긴 강의를 듣고 있는데, 필기할 수 있는 메모지가 포스트잇 한 장뿐이라고 상상해보자. 이 메모지가 바로 RNN의 은닉상태(\(h\))임.
      • 과정
        1. 첫 번째 시점(\(h_1\)): 강의 시작. \(\Rightarrow\) 강사(입력 \(x_1\))가 “중요한 건 A입니다”라고 말함. 학생은 깨끗한 메모지에 “핵심: A”라고 적음. 이것이 \(h_1\)임. 아주 선명함.
        2. 두 번째 시점(\(h_2\)): 덮어쓰기의 시작. \(\Rightarrow\) 강사(입력 \(x_2\))가 이어서 “그리고 B도 기억하세요”라고 말함. RNN의 수식 \(h_t = \tanh(W_{hh} \cdot h_{t-1} + W_{xh} \cdot x_t + b)\)가 하는 일은 다음과 같음.
        • 학생은 새 종이를 꺼낼 수 없음.
        • 기존 메모지(\(h_{t-1}\))에 적힌 내용을 지우개로 살짝 문지르고(가중치 \(W_{hh}\) 곱하기), 그 위에 새 정보(\(x_t\))를 겹쳐서 적어야 함.
        • 결과적으로 메모지엔 “핵심: A”가 약간 번진 채로, 그 위에 “추가: B”가 겹쳐 쓰이게 됨. 이것이 새 은닉상태 \(h_2\)임.
        1. 세 번째 시점(\(h_3\)): 점점 희미해지는 기억. \(\Rightarrow\) 강사(입력 \(x_3\))가 또 “C는 시험에 나옵니다”라고 함. 학생은 또 다시 이미 지저분해진 그 메모지(\(h_2\))를 지우개로 문지르고, 그 위에 C 내용을 덧씀. 이제 맨 처음에 적었던 “A”라는 글씨는 여러 번의 덧칠과 지우개질 때문에 거의 알아볼 수 없게 됨.
    • 왜 덮어쓰기인가?
      • RNN의 수식은 이전 단계의 기억을 보존하라는 명령이 없음. 매 순간 이전 기억과 현재 입력을 믹서기에 넣고 갈아서 새로운 소스를 만들어냄.
      • 매 시점마다 과거의 기억을 담은 그릇(\(h_{t-1}\))을 비우고, 새로운 소스(\(h_t\))로 그릇을 완전히 덮어써버리는(overwrite) 방식임.
      • 이 과정을 10번, 20번 반복하면? 당연히 맨 처음에 넣었던 재료(초기정보, \(h_1\))의 맛이나 색깔은 나중에 추가된 강력한 소스에 묻혀서 흔적도 없이 사라지게 됨.
      • 이것이 바로 기본적인 RNN이 문장이 조금만 길어져도 앞부분의 중요한 주어나 문맥을 까먹어버리는 이유임.

3) RNN의 한계: “단기 기억상실증”

A. RNN의 한계

  • RNN은 이론적으로 완벽해 보이지만, 실제로는 ’기억력’이 좋지 않음.
  • 문제: “나는 프랑스에서 태어났고, …(중략) … 그래서 나는 …어를 유창하게 구사한다.”
  • … 부분에 수십 개의 단어가 있다면, RNN은 맨 처음에 입력된 “프랑스”라는 정보를 마지막 시점까지 전달하지 못함. “프랑스” 정보가 희미해져 “불어”를 예측하기 어려워짐.
  • 이를 “장기 의존성 문제(long-term dependency problem)”라고 부름. \(\Rightarrow\) 문장의 앞부분과 뒷부분이 멀리 떨어져 있을 때, 앞부분의 정보를 기억하지 못하는 문제임.

B. 기울기 소실(vanishing gradient) 문제

  • 원인: RNN의 “장기 의존성 문제”를 일으키는 기술적인 주범임.
  • 개념
    1. 훈련(학습): 모델이 예측을 틀렸을 때, “정답에 더 가까워지도록 가중치를 수정하라”는 신호(loss)를 뒤에서 앞으로 전달함(이를 ’역전파[backpropagation]’라고 함).
    2. 기울기(gradient): 이 ’수정신호’의 세기(방향과 크기)를 의미함.
    3. 문제: RNN이 시퀀스를 거슬러 올라가며 이 신호를 전달할 때, 1보다 작은 값(활성화 함수의 미분값)이 계속 곱해짐.
    4. 비유: “프랑스”라는 정보가 “불어” 예측에 중요했다는 신호가 \(t=100\)에서 \(t=1\)로 거슬러 올라가며 전달되어야 함. 하지만 “신호 * 0.9 * 0.9 * … * 0.9”(수십 번) 곱해지면, 신호의 세기는 0에 가깝게 사라짐.
  • 결과: “프랑스”를 입력받은 \(t=1\) 시점의 가중치는 “네가 틀렸다”는 신호를 받지 못하고, ’장기기억’을 학습할 기회를 잃어버림(반대로 신호가 1보다 크면 무한대로 폭주하는 기울기 폭주(exploding gradient) 문제가 발생하기도 함).

4) LSTM의 등장: 똑똑한 기억 관리 시스템

A. LSTM의 원리

  • RNN의 ‘기울기 소실’ 문제를 해결하기 위해 등장한 것이 바로 LSTM(long short-term memory, 장단기 기억망)임.
  • 핵심 아이디어: RNN처럼 “무조건 기억을 덮어쓰는” 것이 아니라, “선택적으로 기억을 관리”함.
  • 핵심 구조: LSTM 셀(cell)은 2개의 기억 라인을 가짐.
    1. 은닉상태(hidden state, \(h_t\)): RNN과 동일. 현재 예측에 사용할 ‘단기기억’.
    2. 셀 상태(cell state, \(C_t\)): LSTM의 핵심! 장기기억을 위한 전용 ‘고속도로’.
  • 기울기 소실 해결: 이 ‘셀 상태’(\(C_t\)) 라인은 역전파 시 정보가 ‘곱셈’이 아니라 ’덧셈’ 연산으로 전달되도록 설계됨. 덕분에 기울기(신호)가 거의 그대로 흘러가서 ’기울기 소실’이 발생하지 않음.

B. 일반 역전파 vs. 시간을 거슬러 올라가는 역전파

  • 일반 역전파(standard backpropagation)
    • 일반적인 신경망은 입력(문제)을 넣으면 출력(답)이 한 번에 나옴.
    • 채점과정: 정답지와 비교해서 틀렸으면, “아, 이 문제는 3번 공식을 잘못 써서 틀렸네. 그 공식을 수정하자”라고 딱 한 번 피드백을 줌.
    • 특징: 시간이 흐르지 않음. 그냥 정지된 상태에서 한 번의 계산과정에 대한 오차만 수정하면 끝임.
  • 시간을 거슬러 올라가는 역전파(backpropagation through time, BPTT)
    • RNN은 문장(시퀀스)을 다룸. “나는 너를 사랑해”라는 문장을 처리한다면, ‘나는’ \(\rightarrow\) ‘너를’ \(\rightarrow\) ‘사랑해’ 순서로 시간의 흐름에 따라 데이터를 처리함.
    • 중요한 점은, 이 세 단어를 처리하는 모델의 두뇌(가중치)가 모두 똑같다는 점임. \(\Rightarrow\) 가중치 공유.
    • 문제발생: 만약 마지막에 “사랑해”가 아니라 엉뚱하게 “미워해”라고 잘못 예측했다고 가정하자. 그럼 누구의 잘못일까?
      1. 마지막에 “미워해”를 내뱉은 순간의 두뇌?
      2. 중간에 “너를”을 처리하던 순간의 두뇌?
      3. 맨 처음 “나는”을 처리하던 순간의 두뇌?
    • 채점과정(BPTT): 범인은 이 1, 2, 3번 모든 순간에 관여한 똑같은 그 두뇌임. 그래서 BPTT는 다음과 같이 작동함.
      1. 현재 시점(끝)에서 발생한 오차를 확인함.
      2. 시간을 거꾸로 되감기(rewind) 시작함.
      3. “마지막 순간엔 이만큼 잘못했고, 그 바로 전 순간엔 요만큼 잘못했고, 맨 처음 순간엔 저만큼 잘못했네…” 하면서 과거의 모든 시점들을 쭉 거슬러 올라가며 오차(책임)를 낱낱이 따져 물음.
      4. 이렇게 시간여행을 하며 모은 모든 책임들을 합쳐서, 공유된 그 ’하나의 두뇌(가중치)’를 딱 한 번 업데이트함.
  • 두 역전파 간 결정적 차이점: 오차의 책임을 묻기 위해 ’과거의 기록’을 들춰보는가, 안 보는가?
    • 일반 역전파: 과거를 안 보지 않음. 현재 결과만 보고 딱 한 번 수정함. \(\Rightarrow\) 정적인 데이터.
    • BPTT: 시간 축을 따라 과거로 여행을 떠나서, 그동안 쌓인 모든 단계의 오차를 합산해서 수정함. \(\Rightarrow\) 동적인 순차 데이터.
    • 요약: RNN은 순서와 문맥이 중요한 데이터를 다루기 때문에, 필연적으로 과거의 실수들까지 모두 고려해서 현재를 수정해야 하는 BPTT 방식이 필수적인 것.

C. LSTM과 세 개의 문

  • 이 셀 상태(장기 기억)를 세 개의 문(gate)을 통해 관리함. 이 게이트들은 ‘시그모이드(sigmoid)’ 활성화 함수를 사용하여 0(정보를 완전히 차단) ~ 1(정보를 완전히 통과) 사이의 값을 출력함.
  • forget gate(망각 게이트)*
    • 역할: “이전 장기기억(\(C_{t-1}\))에서 어떤 정보를 잊어버릴까?”
    • 예시: “나는 밥을 먹었다. 그리고 그는 학교에 갔다.”
    • “그는”이라는 새로운 주어가 입력되는 순간, forget gate는 ’나’라는 주어 정보는 이제 덜 중요하니 ’셀 상태’에서 잊어버리도록 결정함([EX] 0.1의 가중치만 줌).
  • input gate(입력 게이트)
    • 역할: “현재 입력(\(x_t\))에서 어떤 정보를 새로 기억할까?”
    • 예시: 위 문장에서 “그는”이라는 새로운 주어 정보를 중요하다고 판단하고(input gate), ’장기기억(셀 상태)’에 추가함.
  • output gate(출력 게이트):
    • 역할: “현재의 장기기억(\(C_t\))을 바탕으로 무엇을 말할까(출력/단기기억 \(h_t\)으로 내보낼까)?”
    • 예시: “그는”이라는 주어를 장기기억에서 확인하고, “학교에”라는 입력을 받아 다음 예측에 필요한 정보(“그는 학교에…”)를 ’단기기억(\(h_t\))’으로 내보냄.

[그림 2] LSTM의 원리와 작동방식

D. GRU(gated recurrent unit)

  • 개념: 2014년에 제안된, LSTM의 복잡한 구조를 단순화시킨 모델임.
  • 특징
    • ’셀 상태(\(C_t\))’와 ’은닉상태(\(h_t\))’를 하나의 ’은닉상태(\(h_t\))’로 통합함(기억 라인이 한 개).
    • 게이트를 세 개(forget, input, output)에서 두 개로 줄임:
      • reset gate: LSTM의 forget gate와 유사. \(\Rightarrow\) 과거의 기억을 얼마나 ’리셋’할지 결정.
      • update gate: LSTM의 input gate와 유사 \(\Rightarrow\) 현재 정보를 얼마나 ’업데이트’할지 결정.
  • 장점
    • LSTM보다 파라미터 수가 적어 계산속도가 빠르고, 데이터가 적을 때 과적합(overfitting) 위험이 적음.
    • 많은 경우 LSTM과 비슷한 성능을 보여주므로, 모델을 가볍고 빠르게 만들어야 할 때 좋은 대안이 됨.

(2) 텍스트 생성의 두 가지 접근법

  • RNN/LSTM이라는 ’기억부품’을 알았으니, 이제 이것으로 ’문장생성’이라는 제품을 만드는 두 가지 방법을 알아봄.

1) 접근법 1: 언어 모델 \(\Rightarrow\) “앵무새”

  • 목표 주어진 텍스트의 ’스타일’을 학습하여, 다음에 이어질 텍스트를 예측함(\(P(w_t | w_1, ..., w_{t-1})\) 확률 모델).
  • 예시: 윤동주의 ’서시’를 학습하는 모델(단순 텍스트 생성)
    • 입력: “나는 오늘”.
    • 출력: (답변이 아니라) “하늘을 우러러…”처럼 이어지는 문장을 생성함.
    • 구조: 하나의 LSTM 모델만 사용함.

A. 모델 학습(훈련)

  • 문장 생성은 “다음에 올 글자 맞추기” 게임과 같음.
  • 데이터 준비(sliding window 방식)
    • 원본 텍스트: “나는 오늘 학교에 갔다”
    • 글자(음절) 단위 예시(시퀀스 길이 3):
      • 입력(X): [“나”, “는”, ” ”] \(\rightarrow\) 타겟(Y): [“오”]
      • 입력(X): [“는”, ” “,”오”] \(\rightarrow\) 타겟(Y): [“늘”]
      • 입력(X): [” “,”오”, “늘”] \(\rightarrow\) 타겟(Y): [” ”]
      • … 이렇게 텍스트 전체를 학습 데이터로 만듦.
  • 학습
    • 모델(LSTM)은 세 개의 글자가 주어졌을 때, 네 번째 글자가 무엇일지 맞추는 확률을 계산함(softmax 함수 사용).
    • 정답과 비교하여 틀렸으면(loss 계산), 정답을 더 잘 맞추도록 모델 내부의 가중치를 업데이트함(역전파).

B. 모델 생성(예측)

  • 학습이 완료된 모델에게 ’시작 단어/글자(seed text)’를 줌.
    1. 시드 텍스트 입력: “나는 오늘”
    2. 예측: 모델이 “나는 오늘” 다음에 올 글자로 ” “(공백)을 가장 높은 확률로 예측함.
    3. 다음 입력 준비: 예측된 ” “(공백)을 시드 텍스트에 추가함. 새로운 입력은”는 오늘 “이 됨(길이 3을 유지하기 위해 맨 앞”나”는 버림).
    4. 예측: 모델이 “는 오늘” 다음에 올 글자로 “학”을 예측함.
    5. 반복: 이 과정을 원하는 길이만큼 반복함.(예측값을 다시 입력으로 사용) \(\Rightarrow\) 이처럼 자신의 예측을 다시 입력으로 사용하는 방식을 자기회귀(autoregressive)라고 부름.
  • 자기회귀(autoregressive)란?
    • 생성 모델에서 가장 중요한 개념 중 하나임.
      • 정의: 자신(auto)의 과거 행동이 다시 돌아와서(regressive) 현재의 행동을 결정하는 방식임.
      • 작동원리
        1. 모델이 자신의 입으로 첫 글자 “안”을 뱉음.
        2. 이 “안”이라는 글자는 공중으로 사라지는 게 아니라, 다시 모델 자신의 입(입력)으로 들어감.
        3. 모델은 자기가 뱉은 “안”을 듣고, 그 다음 글자 “녕”을 생각함.
        • 핵심: \(t\) 시점의 출력(\(y_t\))이 \(t+1\) 시점의 입력(\(x_{t+1}\))이 됨.
      • 왜 중요한가?: 이 방식 때문에 텍스트 생성은 한 번에 와르르(병렬로) 만들 수 없고, 한 땀 한 땀(순차적으로) 만들어야 함. 그래서 생성속도가 상대적으로 느림.
    • 한계: 이 모델은 ‘질문’을 이해하지 못함. “오늘 뭐해?”라고 물어도, 학습한 ’서시’ 스타일로 “하늘을 우러러…”라고 이어갈 뿐, ’대화’가 불가능함. \(\Rightarrow\) 챗봇을 만들 수가 없음!

2) 접근법 2: Seq2Seq(encoder-decoder) \(\Rightarrow\) “대화 상대”

  • 목표: “입력 시퀀스”를 이해하고, 그에 맞는 “출력 시퀀스”를 생성함.
  • 예시: 챗봇(질문 \(\rightarrow\) 답변), 번역기(한국어 \(\rightarrow\) 영어).
  • 구조: 두 개의 LSTM 모델(인코더[encoder], 디코더[decoder])을 사용함.

A. Seq2Seq 구조: “질문 이해자”와 “답변 생성자”

  • 인코더(encoder): 질문 이해 담당
    • 역할: 입력된 문장(질문)을 한 글자씩 읽음.
    • 결과물: 문장 전체의 의미/문맥을 압축한 문맥 벡터(context vector)를 생성함. \(\Rightarrow\) 질문 문장을 끝까지 읽고, 문장 전체의 의미를 압축한 요약본을 만드는 것!
    • 작동: “오”, “늘”, ” “,”뭐”, “해”, “?” 까지 모두 읽음. 인코더의 중간 출력값(\(y_t\))은 모두 버림.
      • 중간 출력값(\(y_t\)): Seq2Seq 구조의 인코더에서 생성되는 각 시점의 예측결과.
      • Seq2Seq 인코더의 목적은 “다음 단어를 맞추는 것”이 아니라, “문장 전체의 의미를 압축하는 것.” 따라서 각 단어를 읽을 때마다 예측을 내놓을 필요가 없음.
      • 예를 들어 “오늘 뭐해?”라는 문장을 인코더가 처리할 때:
        1. “오늘”을 읽고 나서 “다음 단어는 ’뭐’일 것이다”라고 예측할 수 있음(이것이 중간 출력값 \(y_t\)).
        2. 하지만 Seq2Seq는 이 예측값을 사용하지 않고 버림.
        3. 대신 “오늘”이라는 정보를 내부 상태(hidden state, \(h_t\))에 저장하여 다음 단계로 넘김.
        4. 이렇게 마지막 단어 “?”까지 다 읽고 난 후, 최종적으로 남은 내부 상태(문맥 벡터)만을 디코더에게 전달.
        5. 인코더는 말을 내뱉지 않고(중간출력 무시), 끝까지 듣고 생각만 정리하는(내부 상태 업데이트) 역할만 수행한다고 이해하면 됨.
    • 오직 마지막 시점의 최종 내부 상태(\(h_n\))최종 셀 상태(\(c_n\))만 ’문맥 벡터’로 사용함. 이 벡터가 “오늘 뭐해?”라는 질문의 의도를 숫자로 압축한 결과물임.
  • 디코더(decoder): 답변 생성 담당
    • 역할: 인코더가 전달한 [문맥 벡터]를 받아서, 답변 문장을 한 글자씩 생성함.
    • 작동(예측 시)
      1. 인코더의 [문맥 벡터]를 자신의 초기 상태(initial state: \(h_0, c_0\))로 받음(이것이 챗봇이 “질문을 이해”하는 방식임).
      2. 디코더는 첫 입력으로 답변의 시작을 알리는 신호(\(t\))를 받음.
      3. \(t\)와 ’문맥 벡터’를 조합하여 첫 글자 “그”를 예측함.
      4. 방금 예측한 “그”를 다음 시점(\(t=2\))의 입력으로 넣음.
      5. “그”와 이전 상태를 조합하여 “냥”을 예측함.
      6. …(자기회귀 방식 반복) …
      7. 답변의 끝을 알리는 신호(n)가 나올 때까지 생성을 반복함.

[그림 3] Seq2Seq의 원리와 작동방식

B. Seq2Seq 학습(training): 교사강요(teacher forcing)

  • ‘서시’ 모델과 데이터 구조가 다름. 챗봇은 세 가지 데이터 셋이 필요함.
    • 데이터: ChatbotData.csv(Q & A 쌍)
    • 예시
      • Q: “뭐해?”
      • A: “그냥 있어.”
  • 데이터를 다음과 같이 세 가지로 가공함(원핫 인코딩됨).
    1. encoder_input_data(인코더 입력): 질문 원본. \(\Rightarrow\) [“뭐”, “해”]
    2. decoder_input_data(디코더 입력): 시작신호(\(t\))가 붙은 답변. \(\Rightarrow\) [“\(t\)”, “그”, “냥”, ” “,”있”, “어”]
    3. decoder_target_data(디코더 타겟): 종료신호(\(n\))가 붙고 한 칸씩 밀린 답변. \(\Rightarrow\) [“그”, “냥”, ” “,”있”, “어”, “\(n\)”]
  • 왜 이렇게 복잡할까?: 교사강요 때문임.
  • 훈련 시 모델의 할 일
    • 인코더: (1) encoder_input_data(“뭐해”)를 읽고 [문맥 벡터]를 만듦.
    • 디코더: [문맥 벡터]와 (2) decoder_input_data(“\(t\)그냥 있어”)를 한꺼번에 받음.
    • 이때 디코더의 출력(예측)이 (3) decoder_target_data(“그냥 있어\(n\)”)와 정확히 일치하도록 훈련시킴.
  • 교사강요란?
    • 디코더가 \(t\)를 입력받고 “그” 대신 “밥”을 예측했다고 가정해봄.
    • 자기회귀로만 학습하다 보면, 이 “밥”을 다음 입력으로 넣어 “밥 먹어”처럼 문맥에는 맞지만 정답(“그냥 있어”)과는 거리가 먼 답변을 생성할 수 있음.
    • 손실함수의 고지식함: 손실함수(채점관)는 융통성이 전혀 없는 매우 고지식한 선생님임. 이 채점관은 문장이 말이 되는지를 보는 게 아니라, 정해진 위치에 정해진 글자가 있는가?만 확인함.
    • 오류전파: 훈련 초기에 이런 실수가 반복되면 모델은 영영 “그냥 있어”라는 정답을 배울 수 없음. \(\Rightarrow\) 앞단의 작은 실수 하나가 뒷단의 모든 예측을 ’정답지’와 어긋나게 만들어, 문장이 아무리 자연스러워도 점수는 0점을 받게 되는 현상을 오류전파라고 함.
    • 따라서 훈련 시에는 모델의 예측(실수)과 상관없이, 무조건 정답(decoder_input_data)을 다음 스텝의 입력으로 넣어주어 학습속도를 높임. \(\Rightarrow\)\(t=1\)일 때 \(t\)를 넣고, \(t=2\)일 때 모델의 예측인 “밥” 대신 정답인 “그”를 강제로 주입함.
    • Keras의 RNN 레이어는 이 ‘교사강요’ 방식을 기본으로 훈련을 수행함.

C. Seq2Seq 예측

  • 예측(실제 챗봇 사용) 시에는 ’교사강요’를 사용하지 않음. 타겟(정답)이 없기 때문임. \(\Rightarrow\) 이 때문에 훈련용 모델과 예측용 모델을 별도로 구축해야 함(가중치는 공유).
  • 예측용 모델의 예시
    1. 사용자가 “뭐해?”라고 질문함.
    2. 인코더 모델(encoder_model): “뭐 해?”를 읽고 이해한 다음 [문맥 벡터]를 생성함.
    3. 디코더 모델(decoder_model)
      • (Loop 1) [문맥 벡터]와 시작신호 \(t\)를 입력받음.
      • (Loop 1) “그”를 예측하고, 업데이트된 문맥 벡터(상태)를 반환함.
      • (Loop 2) 업데이트된 문맥 벡터와 방금 예측한 “그”를 입력받음.
      • (Loop 2) “냥”을 예측하고, 또다시 업데이트된 문맥 벡터를 반환함.
      • …(자기회귀 방식 반복) …
      • (Loop 6) 끝 신호 n를 예측함.
    4. 루프가 종료되고, 챗봇은 “그냥 있어”라는 최종 답변을 반환함.

(3) keras3 패키지 핵심 함수 및 논항 해설

  • Seq2Seq 챗봇 실습 코드는 keras_model_sequential()이 아닌 Keras 함수형 API(functional API)를 사용함.

1) Keras의 두 가지 모델링 방식

  • keras_model_sequential()(순차 모델)
    • 개념: %>% 파이프라인처럼 레이어를 차곡차곡 쌓는 가장 간단한 방식임.
    • 구조: input \(\rightarrow\) Layer 1 \(\rightarrow\) Layer 2 \(\rightarrow\) output.
    • 레이어(층)란?
      • 입력 레이어: 데이터가 모델 안으로 들어오는 입구. “어떤 모양의 데이터가 들어올 거야”라고 미리 약속하는 곳.
      • 은닉 레이어: 입력과 출력 사이에 숨어 있어서 ’은닉’이라고 부름. 실제 학습과 연산이 일어나는 곳.
      • 출력 레이어: 최종 결과를 내보내는 곳. [EX] OX 퀴즈(이진 분류)라면: 0~1 사이의 확률 한 개 출력.
    • 한계: 반드시 입력 1개, 출력 1개여야 함.
    • 용도: 단순한 분류기, ‘서시’ 같은 언어 모델에 적합함.
  • keras_model(inputs = ..., outputs = ...)(함수형 API)
    • 개념: 레이어를 ’함수’처럼 취급하여, 입력과 출력을 자유롭게 연결하는 방식임.
    • 필요성
      • Seq2Seq 모델은 훈련(학습) 시 두 개의 입력(질문, 답변 시작)과 한 개의 출력(답변 타겟)을 가짐.
      • 디코더 모델은 예측(실제 문장 생성) 시 두 개의 입력(이전 글자, 이전 상태)과 두 개의 출력(다음 글자, 다음 상태)을 가짐.
    • 이처럼 입력과 출력이 여러 개인 복잡한 모델은 반드시 순차 모델이 아닌 함수형 API로 구현해야 함.

2) 함수형 API 더 자세히 알아보기

A. 왜 함수형 API인가?(순차 모델의 한계)

  • 순차 모델(keras_model_sequential)
    • 원리: 가장 단순하고 직관적인 방법. 마치 김밥 말기와 같음. 밥 위에 단무지, 햄, 시금치를 순서대로 층층이 쌓아 올리는 방식.
    • 한계: 하지만 김밥 옆구리에 치즈를 따로 넣거나, 김밥 두 줄을 합쳐서 왕김밥을 만드는 복잡한 구조는 만들 수 없음. \(\Rightarrow\) 입력이 한 개이고 출력이 한 개인 일직선 구조만 가능.
  • 함수형 API(keras_model): 훨씬 자유로운 뷔페 접시 담기와 같음.
    • 특징
      • 다중 입력: 밥(input 1)과 국(input 2)을 따로 받아서 처리할 수 있습니다([EX] 질문 + 답변 시작 토큰).
      • 다중 출력: 메인 요리(output 1)와 디저트(output 2)를 동시에 내놓을 수 있습니다([EX] 다음 단어 예측 + 감정분류[긍정 vs. 부정]).
    • 핵심: 레이어(layer)를 함수(function)처럼 취급. 입력 데이터 x를 함수 layer에 넣으면 출력 데이터 y가 나온다는 수학적 개념(\(y = f(x)\))을 그대로 코드로 구현한 것!

B. 함수형 API의 3단계 프로세스

  • 함수형 API는 “정의(define) \(\rightarrow\) 연결(connect) \(\rightarrow\) 모델 생성(create)”의 3단계로 진행됨.
  • 비유: 정수기 설치 과정
    1. 정의(부품 준비): 필터(layer)와 수도관(input)을 책상 위에 꺼내놓음.
    2. 연결(배관 작업): 수도관(input)을 1차 필터(layer 1)에 연결하고, 1차 필터 출구를 2차 필터(layer 2) 입구에 연결. \(\Rightarrow\) 물(데이터)이 흐르는 길을 만드는 것.
    3. 모델 생성(제품 케이스 조립): 입수구(input)와 출수구(output)를 지정하여 정수기 뚜껑을 닫고 완성품(model)으로 만듦.

C. 코드 예시와 상세 설명

# 1단계: 부품(layer) 정의(define)
# "이런 모양의 수도관(입력)과 필터(LSTM)를 쓸 거야"라고 선언만 함.

# (1) 입력층 정의(수도관 입구)
# shape: 들어올 데이터의 크기(시간 축은 가변적이니 NA)
encoder_inputs <- layer_input(shape = c(NA, num_encoder_tokens), name = "enc_input")

# (2) 처리층 정의(정수 필터)
# 아직 연결되지 않은 'LSTM 기기' 자체를 만듦.
encoder_lstm_layer <- layer_lstm(units = 256, return_state = TRUE, name = "enc_lstm")

# 2단계: 연결(connect) - 물(데이터)길 만들기
# "입력(x)을 LSTM 기계(f)에 통과시켜 결과(y)를 얻는다" => y = f(x)

# encoder_inputs(물)를 encoder_lstm_layer(필터)에 통과시킴.
# 그 결과물(outputs)과 상태(state)를 받음.
encoder_results <- encoder_lstm_layer(encoder_inputs) 

# 필요한 것만 챙김(우리는 '기억'인 state만 필요)
encoder_states <- encoder_results[2:3] 

# 3단계: 모델 생성(create model)
# "입수구는 여기고, 출수구는 저기인 기계야"라고 최종 확정.

# keras_model() 함수에 시작점(inputs)과 끝점(outputs)을 알려줌.
encoder_model <- keras_model(
  inputs = encoder_inputs, # 시작: 질문 들어오는 곳
  outputs = encoder_states # 끝: 문맥 벡터 나오는 곳
  )

D. 요약

  • 유연성: 함수형 API는 레고 블록처럼 입/출력을 내 마음대로 조립할 수 있어 복잡한 모델(챗봇, 번역기 등) 구현에 필수.
  • 함수 호출: layer_name(input_tensor) 형태는 수학의 함수 \(f(x)\)와 같음. 데이터를 레이어에 통과시킨다는 직관적인 개념.
  • 명시적 흐름: 데이터가 어디서 들어와서(start) 어디로 나가는지(end)를 keras_model(inputs, outputs)로 명확히 지정해줌.

3) array(data = 0L, dim = …)

  • 역할: R의 기본함수로, 딥러닝에 필요한 다차원 배열(텐서)을 초기화해줌.
  • 논항
    • data = 0L: 배열을 0(정수형 L)으로 채움. 원핫 인코딩을 위해 0으로 가득 찬 배열을 먼저 만듦.
    • dim = c(samples, timesteps, features): 3차원 배열 형태를 지정할 수 있음.
      • samples(샘플 수): 총 Q&A 쌍의 개수([EX] 2000개).
      • timesteps(타임스텝)
        • 각 단어를 읽는 한 순간순간. [EX] “오늘 뭐해?” \(\Rightarrow\) “오늘”을 읽을 때가 \(t=1\), “뭐해”가 \(t=2\), “?”가 \(t=3\)!
        • Q&A 쌍의 최대 길이.
        • 예시
          • 질문용: max_encoder_seq_length \(\Rightarrow\) 100자.
          • 답변용: max_decoder_seq_length \(\Rightarrow\) 100자.
      • features(특성 수)
        • 설명: 각 글자를 표현하는 벡터의 차원
        • 예시
          • 원핫 인코딩의 경우: 만약 사전에 등록된 글자가 총 2,000개라면, “그”라는 글자는 [0, 0, …, 1, …, 0]처럼 2,000개의 숫자로 표현됨. 이때 features = 2,000이 됨. \(\Rightarrow\) 우리 실습의 데이터 벡터화 단계가 여기에 해당함(num_encoder_tokens, num_decoder_tokens).
          • 임베딩 레이어를 거친 후: 만약 임베딩 레이어의 크기를 output_dim = 256으로 설정했다면, “그”라는 글자는 256개의 실수([EX] [0.1, -0.5, 0.3, …])로 표현됨. 이때 features = 256이 됨.
    • encoder_input_data[i, t, idx] <- 1은 “i번째 문장의, t번째 글자”에 해당하는 idx번째 인덱스만 1로 바꾸는 원핫 인코딩 과정임.

4) layer_input(shape = …)

  • 역할: Keras 함수형 API 모델의 ’입력층’을 정의함. 모델의 “입구”를 선언하는 것과 같음.
  • 논항
    • shape = c(NA, num_encoder_tokens): 이 입력을 통해 들어올 데이터의 ’형태’를 지정함(배치 크기[샘플 수]는 제외).
      • NA: 첫 번째 차원(타임스텝, 즉 문장 길이)은 가변적일 수 있음을 의미함(max_encoder_seq_length를 넣어도 되지만 NA가 더 유연함).
      • num_encoder_tokens: 두 번째 차원(특성)은 질문사전의 크기, 즉 질문용 원핫 벡터의 크기임. \(\Rightarrow\) 학습 데이터에 있는 모든 질문 문장에 등장하는 ‘고유한 글자(음절’)의 총 개수!

5) layer_lstm(units = …, return_state = …, return_sequences = …)

  • 역할: LSTM 레이어를 정의해줌. Seq2Seq 모델의 핵심 부품.
  • 출력(output)과 상태(state)의 차이점
    • 출력: 다음 레이어(층)로 전달되는 정보. \(\Rightarrow\) return_sequences()로 제어.
    • 상태: 다음 시점(시간)으로 전달되는 정보. \(\Rightarrow\) return_state()로 제어.

A. units

  • LSTM 레이어의 ‘기억용량’(은닉상태와 셀 상태 벡터의 차원 수).
  • 이 숫자가 클수록 더 복잡한 문맥을 기억할 수 있으나, 계산량이 많아짐. 인코더와 디코더는 이 차원 수가 통일되어야 함.

B. return_state

  • 의미: “마지막 시점 당시의 기억(state)도 같이 줄까, 아니면 마지막 시점의 출력만 줄까?”에 대한 설정.
  • return_state = TRUE
    • FALSE(기본값): 마지막 타임스텝의 출력 외에 마지막 은닉상태와 셀 상태를 추가로 반환하지 않음.
    • TRUE(중요!): 마지막 타임스텝의 [출력(output), 은닉상태(state_h), 셀 상태(state_c)]를 R 리스트 형태로 반환함.
  • 인코더에서 사용하는 이유: 인코더는 “질문”을 다 읽은 뒤의 최종 상태(h, c), 즉 ’문맥 벡터’를 디코더에 전달해야 하므로 반드시 TRUE여야 함.
  • 디코더에서 사용하는 이유
    • 디코더는 “그냥 있어n”처럼 ‘문장’을 생성해야 함. \(\Rightarrow\) 즉 각 타임스텝(t \(\rightarrow\) ’그’, ‘그’ \(\rightarrow\) ’냥’, ‘냥’ \(\rightarrow\) ’ ’…)마다 예측출력이 필요하며, 이 출력들을 layer_dense에 전달해야 하므로 반드시 TRUE여야 함.
    • 만약 답변이 “그냥 있어”라면, 마지막 ’어’를 예측한 결과(확률분포)만 나옴. 하지만 디코더는 답변 문장 전체를 만들어야 하므로 FALSE를 쓰면 안 됨!

C. return_sequence

  • 의미: “모든 시점의 출력을 다 줄까, 아니면 마지막 시점의 출력만 줄까?”에 대한 설정.
가. FALSE
a. 설명
  • LSTM은 ’글자’가 아니라 ’상태(벡터)’를 내뱉음
    • LSTM 레이어 자체는 “그냥”, “있”, “어” 같은 실제 글자를 출력하는 게 아님. LSTM은 오직 숫자로 이루어진 은닉상태 벡터(뇌 속 생각)을 출력함.
    • 실제 글자는 이 ’생각(벡터)’이 마지막에 layer_dense(activation = "softmax")를 통과해야 비로소 나옴.
  • return_sequences = FALSE
    • 마지막 타입스텝(시점)의 출력(=은닉상태)만 반환함. \(Rightarrow\) “최종 요약본”만 제공함!
    • return_state = FALSE와의 차이점: 결과적으로만 보자면 마지막 시점의 출력만 반환하도록 한다는 점에서 차이는 없음. 그러나 return_state()는 마지막 은닉상태와 셀 상태까지 추가로 반환할 것인지 여부를 결정하는 함수이므로 return_sequence()와 관장하는 범위가 서로 다름.
    • LSTM이 “그냥 있어”라는 문장을 처리한다고 가정해보자(엄밀히 말하면 디코더가 생성하는 과정이지만, 이해를 돕기 위해 입력처리 관점에서 설명함).
      1. time 1(“그” 처리): 뇌 상태 1이 생성됨.
      2. time 1(“냥” 처리): 뇌 상태 1을 바탕으로 뇌 상태 2가 생성됨.
      3. time 2(“있” 처리): 뇌 상태 3을 바탕으로 뇌 상태 3이 생성됨.
      4. time 3(“어” 처리): 뇌 상태 3를 바탕으로 최종 뇌 상태 4가 생성됨.
b. TRUE와의 차이점
  • TRUE(과정 중심) vs. FALSE(결과 중심)
    • TRUE
      • 내가 “그”를 봤을 때의 생각, “냥”까지 봤을 때의 생각, “있”까지 봤을 때의 생각, “어”까지 다 봤을 때의 생각을 모두 다 주겠다!
      • 출력: [뇌 상태 1, 뇌 상태 2, 뇌 상태 3, 뇌 상태 4] \(\Rightarrow\) 모든 타임스텝(시점)의 출력(=은닉상태)를 모두 반환함.
    • FALSE(기본값)
      • 중간과정은 됐고, 다 처리하고 난 후의 최종결론(요약)만 줘! \(\Rightarrow\) 모든 타임스텝(시점)의 출력(=은닉상태)를 시퀀스 형태로 반환.
      • 출력
        • [뇌 상태 4] 딱 하나만 출력. \(\Rightarrow\) 이 단계에서 얻게 되는 것은 마지막 글자 “어”가 아님. “그”와 “냥”과 “있”을 거쳐 마지막으로 “어”까지 처리하면서 업데이트된 가장 마지막 순간의 은닉상태 벡터(최종 요약된 생각) 하나를 얻게 되는 것임.
        • 이 최종상태 벡터는 비록 마지막 시점에 나왔지만, 앞선 “그냥 있어”라는 전체 문맥의 정보를 압축해서 담고 있는 아주 중요한 벡터임.
나. TRUE
a. 설명
  • 모든 타임스텝의 출력(=은닉상태)을 시퀀스 형태로 반환함.
  • 포맷: [samples, timesteps, units] \(\Rightarrow\) [100, 10, 32]
    • samples(샘플 수): 한 번에 처리하는 문장(데이터)의 개수. \(\Rightarrow\) 데이터가 100개라면 samples = 100.
    • timesteps(타임스텝): 하나의 문장에 들어 있는 단어(또는 글자)의 개수. 시간순서대로 몇 번이나 입력을 넣어야 하는지를 의미함. \(\Rightarrow\) 문장 최대 길이를 100글자로 정했다면 timesteps = 100(100번 동안 순차적으로 읽음).
    • units
      • 각 단어(타임스텝) 하나를 설명하는 숫자의 개수. LSTM 레이어의 units 설정값과 같음. units가 작으면 단어의 의미를 대충 요약한 것이고, 크면 아주 상세하게 분석한 것임.
      • 예시: units = 32라고 설정했다면, 이 레이어는 각 글자를 읽을 때마다 그 의미를 32개의 숫자로 번역해서 내놓음.
  • 디코더에서 사용하는 이유
    • 디코더는 매 타임스텝마다 예측값(“그”, “냥”, “있”, “어”)을 내놓아야 함. \(\Rightarrow\) return_sequences = TRUE를 사용하여 모든 시점의 출력을 시퀀스 형태로 반환받아야 온전한 답변문장을 얻을 수 있음!
b. 의문점
  • 의문점
    • 이론상으로는 인코더 LSTM이 문장을 다 읽고 난 후의 최종 은닉상태(마지막 요약본)에 문장 전체의 맥락정보가 다 압축되어 있어야 함. 그러니 그것만 디코더에게 넘겨주면(return_sequences = FALSE) 충분할 것 같음. 초기 Seq2Seq 모델은 실제로 그렇게 동작했음.
    • 그런데 왜 최신 모델들은 굳이 return_sequences = TRUE로 설정해서 그 많은 중간과정의 기억들을 다 살려두는 걸까?
  • 이유: 병목현상과 이를 해결하기 위한 어텐션(attention) 메커니즘 때문임.
    1. FALSE의 한계: “기억의 병목현상”(닫힌 책 시험)
      • ’김LSTM’이라는 학생이 긴 지문(“오늘 뭐해? …”)을 읽고 나서, 그 내용을 딱 하나의 포스트잇(최종상태 벡터)에 요약해서 시험장(디코더)에 들어가는 상황임.
        • 문제점
          • 지문이 짧으면 괜찮지만, 지문이 엄청 길고 복잡하면? \(\Rightarrow\) 그 작은 포스트잇 하나에 모든 세부정보를 완벽하게 압축해서 담는 건 불가능함. 앞부분의 중요한 정보가 뭉뚱그려지거나 사라지는 정보손실이 발생함.
          • 병목현상: 정보가 하나의 벡터로 몰리면서 막히는 현상이 일어남. 이 상태로 시험(문장생성)을 보면 디코더는 부정확한 요약본에 의존해야 하니 좋은 문장을 만들기 어려움.
    2. TRUE가 필요한 이유: “어텐션 메커니즘”(오픈 북 시험)
      • 이 병목현상을 해결하기 위해 등장한 혁명적인 기술이 바로 어텐션임.
      • 어텐션의 핵심 아이디어: 디코더가 답변을 생성할 때, 인코더가 만들어준 최종 요약본만 보지 말고, 필요할 때마다 인코더가 각 단어를 처리했던 순간의 기억을 다시 ’슬쩍슬쩍 컨닝(attend)’하게 해주자!
      • 비유(오픈 북 시험)
        • 이제 ‘김LSTM’ 학생은 요약 포스트잇만 들고 가는 게 아님. 지문을 읽으면서 각 단어 밑에 메모해뒀던 모든 필기 노트(모든 시점의 은닉상태들)를 통째로 들고 시험장에 들어감.
        • 디코더가 첫 글자(“그”)를 만들려고 할 때, 그냥 만드는 게 아니라 가져온 필기 노트를 쭉 훑어봄. “음, 이 글자를 만들려면 입력문장의 어떤 단어에 집중(attention)해야 하지?” 하고 찾아보는 것임.
    3. 결론: 왜 TRUE인가?
      • 그럼 디코더가 입력문장의 특정 단어(“오늘” 또는 “뭐해”)를 처리했을 당시의 기억을 훔쳐보려면 무엇이 필요할까? \(\Rightarrow\) 바로 그 단어를 처리했을 당시의 은닉상태가 필요함.
      • 그렇기 때문에 우리는 인코더 LSTM에게 이렇게 명령해야 함: “마지막 요약본만 띡 던져주지 말고, 네가 ‘오늘’ 처리할 때 했던 생각, ‘뭐해’ 처리할 때 했던 생각들을 시퀀스(목록)로 다 넘겨줘(return_sequences = TRUE)! 디코더가 나중에 필요할 때마다 갖다 쓸 거니까!”
  • 요약
    • 과거의 방식(어텐션 X): return_sequences = FALSE 사용. 최종 요약본 하나에 의존. 긴 문장에 약함.
    • 현재의 방식(어텐션 O): return_sequences = TRUE 사용. 인코더가 가진 모든 순간의 기억을 디코더에게 넘겨줘서, 디코더가 매 순간 중요한 정보에 집중할 수 있게 함. 훨씬 강력함.
    • 우리가 앞으로 배울 고급 모델들은 대부분 이 어텐션 방식을 사용하므로, 인코더 부분에서 return_sequences = TRUE를 설정하는 것이 일반적임.

6) keras_model(inputs = …, outputs = …)

  • 역할: 함수형 API의 ’입력’과 ’출력’을 연결하여 전체 모델을 조립함.
  • 논항
    • inputs: 모델의 입력층(들). 입력이 여러 개(인코더 입력 + 디코더 입력)일 경우 c(input1, input2)처럼 c(...)로 묶어 전달함.
    • outputs: 모델의 최종 출력층.
  • 인코더 입력(encoder_inputs): “질문” 데이터 전체.
    • 데이터: encoder_input_data(원핫 인코딩된 3D 텐서).
    • 예시: “뭐해?”
  • 디코더 입력(decoder_inputs): “시작 토큰(t)”이 포함된 “답변” 데이터(교사강요용). \(\Rightarrow\) 훈련을 시켜야 하니까 인코더와 디코더 입력이 모두 필요한 것!
    • 데이터: decoder_input_data(원핫 인코딩된 3D 텐서).
    • 예시: “t그냥 있어”
  • 출력(outputs = ...)
    • 디코더 출력(decoder_outputs): “종료 토큰(n)”이 포함된 “답변 정답” 데이터.
    • 데이터(타겟): decoder_target_data.
    • 예시: “그냥 있어n”

7) fit(x = …, y = …, …)

  • 역할: 모델을 훈련시킴.
  • 논항
    • x: 훈련 입력 데이터. keras_model 정의 시 inputs = c(encoder_inputs, decoder_inputs)로 두 개(인코더, 디코더)를 지정했으므로, x에도 list(encoder_input_data, decoder_input_data)로 두 개를 순서에 맞게 전달해야 함.
    • y: 훈련 타겟 데이터. \(\Rightarrow\) 여기서는 decoder_target_data.
    • batch_size: 한 번의 가중치 업데이트에 사용할 샘플(문장 쌍) 뭉치의 개수([EX] 64개).
    • epochs: 전체 데이터셋을 몇 번 반복 학습할지 결정.
    • validation_split: 훈련 데이터 중 일부([EX] 20%)를 검증용으로 사용하여, 모델이 과적합(overfitting)되고 있지는 않은지 모니터링함.

8) initial_state = …

  • 역할: LSTM 레이어가 0 벡터가 아닌, 특정 상태(기억)에서 계산을 시작하도록 강제함.
  • 사용처
    • 훈련 시: decoder_lstmencoder_statesinitial_state로 받아, 질문의 문맥을 갖고 답변 생성을 시작함.
    • 예측 시: decoder_model이 루프를 돌 때, t=2 시점의 initial_statet=1 시점에서 반환된 new_state가 됨.
  • 설명: “기억의 바통 터치”
    • 비유: 챗봇이 답변을 생성하는 과정은 혼자서 북 치고 장구 치는 이어달리기와 같음.
      • 경기장 트랙: while 루프(답변 생성이 끝날 때까지 계속 도는 반복문).
      • 선수: 디코더 모델(decoder_model).
      • 바통
        • 상태(state = 기억/문맥) \(\Rightarrow\) 코드에서는 states_value.
        • 이 바통에는 “지금까지 무슨 이야기를 했고, 문맥이 무엇인지”가 압축되어 적혀 있음. 선수가 달리기 위해서는 반드시 이 바통을 손에 쥐어야 함.
  • 상황: 챗봇이 “그냥 있어”를 만드는 과정
    • 준비 단계: 인코더로부터 첫 바통 받기. 인코더가 질문(“오늘 뭐해?”)을 다 읽고, 그 핵심 내용을 담은 첫 번째 바통(초기 문맥 벡터)을 만들어 디코더에게 넘겨줌.
    • \(t=1\) 시점: 첫 번째 바퀴 돌기
      1. 출발(initial_state 주입): 디코더 선수가 인코더가 준 첫 번째 바통을 손에 쥠. 이것이 \(t=1\) 시점의 initial_state가 됨.
      2. 달리기(예측): “시작 신호(t)”를 보고 바통의 내용을 참고해서, 첫 글자 “그”를 예측함.
      3. 바통 업데이트(new_state 반환)
      • 한 바퀴를 다 돎. \(\Rightarrow\) 이렇게 되면 선수는 손에 쥐고 있던 바통에 새로운 정보를 추가하게 됨.
      • 원래 바통 내용 + “방금 ’그’라는 글자를 뱉었음” = 업데이트된 새 바통(new_state)!
    • \(t=2\) 시점: 두 번째 바퀴 돌기
      1. 바통 터치(initial_state 주입): 이제 두 번째 글자를 예측해야 함. 다시 출발선에 선 디코더 선수는 어떤 바통을 쥐어야 할까? \(\Rightarrow\) 바로 직전 바퀴(\(t=1\))를 완주하고 받은 “업데이트된 새 바통”을 쥐어야 함! 즉 \(t=1\)에서 반환된 new_state\(t=2\)의 출발 준비물인 initial_state가 되는 것.
      2. 달리기(예측): “이전 글자(그)”를 보고, 손에 쥔 “새 바통”을 참고해서, 다음 글자 “냥”을 예측함.
      3. 바통 업데이트: 또다시 바통에 “방금 ’냥’을 뱉었음”이라는 정보를 업데이트함.
    • 이 과정을 문장이 끝날 때까지 반복.
  • 요약
    • decoder_model은 예측을 수행할 때마다 두 가지를 내놓음: 예측한 글자 & 업데이트된 기억(상태).
    • 이 업데이트된 기억을 잘 받아두었다가, 다음 루프를 돌 때 모델의 입력(initial_state)으로 다시 넣어주는 것.

(4) Keras 3는 R에서 어떻게 작동하는가?

1) 핵심 결론

  • 우리는 R에서 Keras 3.0+의 모든 기능을 사용하기 위해, keras3 패키지를 사용함.
  • 이는 RStudio가 만들었던 기존 keras 패키지와는 다른, Keras 3.0+의 멀티-백엔드(TensorFlow, PyTorch, JAX)를 R에서 공식적으로 지원하기 위한 새로운 표준 패키지임.

2) 기존 keras 패키지

  • 제작: RStudio, Francois Chollet.
  • 목적: R 사용자가 Python의 Keras 2 버전을 쉽게 사용하도록 만든 인터페이스임.
  • 특징: Keras 2는 TensorFlow하고만 강력하게 결합된 ’일체형 엔진’이었음.

3) 새로운 keras3 패키지(우리가 사용할 패키지)

  • 제작: Tomasz Kalinowski(R의 Keras/TensorFlow 커뮤니티 기여자)
  • 목적: Python Keras 3.0의 가장 큰 변화인 멀티 백엔드를 R에서 완벽하게 지원하기 위함.
  • 특징: Keras 3는 이제 껍데기(API, 설계도)이며, 실제 계산 ’엔진(백엔드)’으로 TensorFlow, PyTorch, JAX 중 하나를 선택할 수 있음.
  • keras3 패키지는 R 사용자가 이 세 가지 엔진을 모두 선택하여 Keras 3 API를 사용할 수 있도록 만든 새로운 ’조종기’임.

4) 우리 실습 코드의 구성

  • library(keras3): “새로운 R 운전대”를 사용함. 이는 Keras 3.0+의 함수체계(임베딩, 전처리, 모델링 API)를 R에서 사용하겠다는 의미임.
  • library(tensorflow): TensorFlow 엔진(백엔드)을 사용함. Keras 3는 API일 뿐, 실제 계산을 수행할 엔진이 필요함. 우리는 그 엔진으로 TensorFlow를 지정할 것임.
  • 실제 작동: R keras3가 Python Keras 3.0+를 호출하고, 이 Python Keras 3.0+가 tensorflow 백엔드를 사용하여 계산함.
  • 결론: 우리가 library(keras3)library(tensorflow)를 함께 사용하는 것은 TensorFlow를 백엔드로 사용하는 Keras 3를 R에서 가장 올바른 방법으로 실행하는 과정임.

2. Keras 3로 Seq2Seq 챗봇 만들기

(1) Seq2Seq 챗봇 만들기

  • 목표: Q&A 데이터를 바탕으로, Seq2Seq(Encoder-Decoder) 모델을 keras3 R 패키지를 사용하여 직접 구축하고 훈련시켜 챗봇의 원리를 이해함.
  • 사용할 데이터: ChatbotData.csv(일상대화 Q&A 쌍).
  • 핵심 원리:
    • 인코더 LSTM: “질문” 시퀀스를 읽어 ‘문맥 벡터’(hidden state, cell state)로 요약 & 압축함.
    • 디코더 LSTM: ’문맥 벡터’를 초기상태로 받아 “답변” 시퀀스를 한 글자씩 생성함.

(2) 라이브러리 로드 및 환경 설정

  • 먼저 실습에 필요한 모든 R 라이브러리를 로드함. Keras 3.0+의 멀티-백엔드를 지원하는 keras3 패키지를 사용함.
# --- 0. 라이브러리 로드 ---  
install.packages("keras3") # Keras 3 R 패키지(최초 1회)  
install.packages("tensorflow") # 백엔드(최초 1회)  

library(reticulate) # 파이썬 가상환경 연결
conda_create("my_env_310", python_version="3.10") # 파이썬 버전이 3.10인 콘다 가상환경 생성(keras3에서 사용되는 tensorflow는 파이썬 버전이 3.10일 때 설치가 가장 용이함)
use_condaenv("my_env_310") # 파이썬 버전이 3.10인 콘다 가상환경 설정
keras3::install_keras(backend = "tensorflow") # Keras 3 및 TF 설치(최초 1회만 하면 됨)

# 만약 keras3::install_keras(backend = "tensorflow")로 설치오류가 나면 다음 코드를 실행해볼 것.
library(reticulate)
library(keras3)

conda_install(
  envname = "my_env_310",
  packages = c("tensorflow", "keras"), # 설치할 파이썬 패키지명
  pip = TRUE # pip를 사용헤야 최신 버전이 설치됨
  )

library(keras3) # Keras 3 R 패키지(멀티-백엔드 지원)  
library(tensorflow) # Keras의 백엔드 '엔진'으로 TensorFlow를 사용  
library(tidyverse) # 텍스트 처리를 위한 str_split, str_length 등 함수 활용.
library(readr) # read_csv로 CSV 파일 읽기  

(3) 데이터 로드 및 전처리

1) 데이터 다운로드 및 로드

  • Seq2Seq 모델을 훈련시키기 위해서는 ’질문’과 ’답변’이 쌍으로 이루어진 데이터가 필요함. songys/Chatbot_data 깃허브 저장소의 ChatbotData.csv 파일을 사용함.
# --- 1. 데이터 로드 및 전처리 ---

# 챗봇 데이터셋 URL  
data_url <- "https://raw.githubusercontent.com/songys/Chatbot_data/master/ChatbotData.csv"  
# 로컬에 저장할 파일명  
csv_file <- "ChatbotData.csv"

download.file(data_url, csv_file)  

# readr 패키지의 read_csv 함수로 CSV 파일을 읽어 R의 tibble로 로드  
data <- read_csv(csv_file, show_col_types = FALSE) # 각 칼럼의 유형이 무엇인지를 보여주지 않아도 된다고 설정.
data <- data <- data[1:2000, ] 
# -----------------------------------------------------------------  
# ※중요※: 전체 데이터(약 12,000개)를 모두 사용하면 훈련에 매우 오래 걸림.  
# GPU가 없는 환경([EX] 개인 노트북)에서는 빠른 실습 및 테스트를 위해  
# 데이터 양을 2000개 정도로 줄일 것을 권고. 
# 구글 Colab은 잦은 오류와 패키지 설치 소요 시간을 고려하여 사용하지 않을 계획임.
# -----------------------------------------------------------------

2) 특수 토큰 추가(시작/종료)

  • Seq2Seq 모델의 디코더는 “답변 생성을 시작하라”는 신호와 “답변 생성을 끝내라”는 신호를 학습해야 함.
  • t(tab): 문장의 시작(start of sequence)을 알리는 토큰으로 사용.
  • n(newline): 문장의 종료(end of sequence)를 알리는 토큰으로 사용.
  • 이를 바탕으로 A(원본 답변) 컬럼을 두 개로 가공함.
    • A_input(디코더 입력): “t” + “원본 답변”([EX] “t그냥 있어”)
    • A_target(디코더 타겟): “원본 답변” + “n”([EX] “그냥 있어n”)
    • 이 작업은 교사강요 훈련을 위해 필수적임.
# 1. 디코더 입력(decoder input): "답변" 앞에 't'를 붙임.  
# 이유: 디코더가 '시작' 신호를 보고 첫 글자 "그"를 예측하도록 훈련시키기 위함.  
data$A_input <- str_c("t", data$A)

# 2. 디코더 타겟(Decoder Target): "답변" 뒤에 'n'을 붙임.  
#    이유: 디코더가 마지막 글자 "어"를 보고 '종료' 신호를 예측하도록 훈련시키기 위함.  
data$A_target <- str_c(data$A, "n")

# 데이터 가공 결과 확인을 위해 상위 3개 행의 특정 열을 출력  
head(data[, c("Q", "A_input", "A_target")], 3) 

(4) 사전 구축 및 토큰화(character-level)

  • 모델이 글자를 이해할 수 있도록, 각 글자에 고유한 번호(인덱스)를 부여한 ’사전(vocabulary)’을 만듦.

1) 고유 글자 추출

  • 질문(encoder)과 답변(decoder)에 사용되는 글자 집합이 다를 수 있으므로(특히 t, n 때문에) 사전을 분리하여 구축함.
# --- 2. 토큰화 및 사전 구축 ---  
# '글자(음절)' 단위로 챗봇을 만듦.

# 1. 입력(질문) 사전에 사용될 모든 고유 글자 추출  
input_chars <- data$Q %>% # 'data'의 'Q'(질문) 열을 선택  
  str_split("") %>% # 모든 문장을 글자 단위로 쪼개 리스트로 만듦  
  unlist() %>% # 리스트를 하나의 긴 벡터로 펼침  
  unique() %>% # 중복된 글자를 모두 제거  
  sort() # 가나다 순으로 정렬

# 2. 출력(답변) 사전에 사용될 모든 고유 글자 추출  
# (시작/종료 토큰(t, n)이 반드시 포함되어야 함)  
target_chars <- c(data$A_input, data$A_target) %>% # 디코더 입력과 타겟 데이터를 하나로 합침  
  str_split("") %>% # 모든 문장을 글자 단위로 쪼갬  
  unlist() %>% # 하나의 긴 벡터로 펼침  
  unique() %>% # 중복 글자 제거(이때 t, n 포함)  
  sort() # 가나다 순으로 정렬

# 3. 사전의 크기(원핫 인코딩의 차원 수가 됨)  
num_encoder_tokens <- length(input_chars)  # 인코더(질문) 사전의 고유 글자 수(모든 질문에 등장하는 고유 글자의 총 개수)  
num_decoder_tokens <- length(target_chars) # 디코더(답변) 사전의 고유 글자 수(모든 답변에 등장하는 고유 글자의 총 개수)

# 사전 크기 출력  
cat("입력(질문) 고유 글자 수:", num_encoder_tokens, "n")  
cat("출력(답변) 고유 글자 수:", num_decoder_tokens, "n")

2) 글자-인덱스 매핑 생성

  • R에서는 Python의 dict 대신 ’이름을 지닌 벡터(named vector)’를 사용하여 매핑을 구현함.
# 4. 글자 -> 인덱스 매핑 생성(R에서는 이름이 있는 리스트/벡터 사용)  
#([EX] ' ' -> 1, '!' -> 2, '?' -> 3 ...)  
input_token_index <- 1:num_encoder_tokens # 1부터 사전 크기까지의 숫자 시퀀스 생성  
names(input_token_index) <- input_chars # 숫자 시퀀스의 '이름'으로 글자 벡터를 할당

#([EX] 't' -> 1, 'n' -> 2, ' ' -> 3 ...)  
target_token_index <- 1:num_decoder_tokens # 1부터 사전 크기까지의 숫자 시퀀스 생성  
names(target_token_index) <- target_chars # 숫자 시퀀스의 '이름'으로 글자 벡터를 할당

# 5. 인덱스 -> 글자 매핑(예측 결과 해독 시 사용)  
#([EX] 1 -> 't', 2 -> 'n', 3 -> ' ' ...)  
reverse_target_char_index <- target_chars # 글자 벡터 생성  
names(reverse_target_char_index) <- 1:num_decoder_tokens # 글자 벡터의 '이름'으로 인덱스를 할당

3) 최대 시퀀스 길이 계산

  • 모든 데이터를 동일한 크기의 행렬에 담기 위해, 가장 긴 문장의 길이를 기준으로 패딩을 수행함.
# 6. 모든 문장을 동일한 길이로 맞추기 위해 가장 긴 문장 길이 찾기.
max_encoder_seq_length <- max(str_length(data$Q)) # 질문 중 가장 긴 글자 수  
max_decoder_seq_length <- max(str_length(data$A_target)) # 답변 중 가장 긴 글자 수(t, n 포함)

# 최대 길이 출력  
max_encoder_seq_length # 최대 질문 길이
max_decoder_seq_length # 최대 답변 길이

4) 데이터 벡터화(원핫 인코딩)

  • 이 단계가 Seq2Seq 실습에서 가장 중요하고 복잡한 부분임. \(\Rightarrow\) 텍스트를 Keras가 입력받을 수 있는 3차원 숫자 배열(텐서)로 변환함.
  • 3D 배열의 형태: [samples, timesteps, features]
    • samples: 문장(Q&A 쌍)의 개수(챗봇 csv 파일의 행 수. \(\Rightarrow\) [EX] 2000쌍.
    • timesteps: 문장의 최대 길이(패딩된 길이). \(\Rightarrow\) [EX] 질문(인코더) 35자, 답변(디코더) 24자.
    • features: 사전의 크기(원핫 벡터의 차원). \(\Rightarrow\) [EX] 질문(인코더)에 사용된 총 글자 수 1105자, 답변(디코더)에 사용된 총 글자 수 1038자.
  • 총 3종의 3D 배열을 생성함:
    1. encoder_input_data: 인코더 입력.
    2. decoder_input_data: 디코더 입력.
    3. decoder_target_data: 디코더 타겟.
# --- 3. 데이터 벡터화(원핫 인코딩) ---  

# 총 샘플 수(문장 쌍의 개수)  
num_samples <- nrow(data)

# 1. 인코더 입력(질문): [2000, 최대 질문 길이, 질문사전 크기]  
#  R의 array() 함수를 사용해 0으로 채워진 3차원 배열을 미리 생성해둠.  
encoder_input_data <- array(0L, dim = c(num_samples, max_encoder_seq_length, num_encoder_tokens))

# 2. 디코더 입력(답변 - 시작 토큰 t 포함): [2000, 최대 답변 길이, 답변사전 크기]  
decoder_input_data <- array(0L, dim = c(num_samples, max_decoder_seq_length, num_decoder_tokens))

# 3. 디코더 타겟(답변 - 종료 토큰 n 포함, 한 스텝 밀림): [2000, 최대 답변 길이, 답변사전 크기]  
decoder_target_data <- array(0L, dim = c(num_samples, max_decoder_seq_length, num_decoder_tokens))

# 모든 샘플(문장 쌍)에 대해 루프를 돎
for(i in 1:num_samples) {  
  # 텍스트를 글자 리스트로 쪼갬  
  input_text <- str_split(data$Q[i], "")[[1]] # i번째 질문을 글자 벡터로 변환  
  target_input_text <- str_split(data$A_input[i], "")[[1]] # i번째 디코더 입력을 글자 벡터로 변환  
  target_text <- str_split(data$A_target[i], "")[[1]] # i번째 디코더 타겟을 글자 벡터로 변환  
    
  # 1. 인코더 입력 벡터화:(i, t, char_idx) 위치에 1을 할당  
  for(t in 1:length(input_text)) { # 현재 문장의 글자 수만큼 반복  
    char <- input_text[t] # t번째 글자(사전에서 char의 인덱스)  
    # [i번째 문장, t번째 글자,] 위치에 1을 찍음  
    encoder_input_data[i, t, input_token_index[[char]]] <- 1  
  } # 인코더 입력 벡터화 루프 종료  
    
  # 2. 디코더 입력 벡터화  
  for(t in 1:length(target_input_text)) { # 현재 문장의 글자 수만큼 반복  
    char <- target_input_text[t] # t번째 글자(사전에서 char의 인덱스)
    decoder_input_data[i, t, target_token_index[[char]]] <- 1 # [i, t, char_idx]에 1 할당(i번째 답변[t로 시작하는 답변], t번째 글자, char_idx번째 인덱스에 1이라는 숫자 부여)  
  } # 디코더 입력 벡터화 루프 종료  
    
  # 3. 디코더 타겟 벡터화(교사강요용)  
  for(t in 1:length(target_text)) { # 현재 문장의 글자 수만큼 반복  
    char <- target_text[t] # t번째 글자  
    decoder_target_data[i, t, target_token_index[[char]]] <- 1 # [i, t, char_idx]에 1 할당(i번째 답변[n으로 끝나는 답변], t번째 글자, char_idx번째 인덱스에 1이라는 숫자 부여)
  } # 디코더 타겟 벡터화 루프 종료  
} # 모든 샘플에 대한 루프 종료

# 생성된 3D 배열의 '형태(shape)' 확인  
dim(encoder_input_data)
dim(decoder_input_data)
dim(decoder_target_data)

(5) Seq2Seq 훈련 모델 구축

  • keras_model_sequential()로는 입력 2개, 출력 1개의 복잡한 구조를 만들 수 없음. Keras 함수형 API(Functional API)를 사용해야 함.
  • 초매개변수: LSTM의 ‘기억용량’(은닉상태와 셀 상태 벡터, 즉 문맥 벡터의 차원)을 256으로 설정함.
# --- 4. Seq2Seq 모델 구축(R keras3) ---

# 하이퍼파라미터: LSTM의 '기억용량'(은닉상태와 셀 상태 벡터의 차원 수)  
# 이 숫자가 클수록 더 복잡한 문맥을 기억할 수 있으나, 계산량이 많아짐.  
latent_dim <- 256 

1) 인코더 정의

  • “질문”을 입력받아 ‘문맥 벡터’(state_h[은닉상태], state_c[셀 상태])를 출력하는 부분임.
# --- 인코더 정의: 질문을 읽고 문맥 벡터(상태)를 생성 ---

# 1. 인코더의 "입력층"을 정의합니다. 모델의 "입구 1"  
# shape = c(NA, num_encoder_tokens):  
# NA: 타임스텝(문장 길이)은 가변적일 수 있음을 의미(padding).  
# num_encoder_tokens: 피처(특성)는 '질문 사전 크기'(원핫 벡터의 차원)  
encoder_inputs <- layer_input(shape = c(NA, num_encoder_tokens), name = "encoder_input")

# 2. 인코더의 "LSTM 레이어"를 '정의'함(아직 연결은 안 함)  
# (layer_lstm 논항 설명 참고)  
encoder_lstm_layer <- layer_lstm(  
  units = latent_dim, # 기억 용량(256차원 벡터)  
  return_state = TRUE, # (핵심!) 마지막 [output, state_h, state_c]를 리스트로 반환  
  name = "encoder_lstm"  
  # return_sequences = FALSE(기본값): 인코더는 '중간 출력'이 필요 없고 '마지막 상태'만 필요함.  
)

# 3. 입력층과 LSTM 레이어를 '연결'함(함수처럼 호출).  
# encoder_inputs가 encoder_lstm_layer를 통과함.  
encoder_outputs_list <- encoder_lstm_layer(encoder_inputs)

# 4. 인코더의 최종 출력(문맥 벡터)을 저장합니다.  
# return_state = TRUE이므로, 반환값은 3개 요소를 가진 리스트임:  
# 1) encoder_outputs_list[[1]]: 마지막 타임스텝의 '출력'(여기선 사용 안 함). "오늘 뭐해?"에서 "?"에 해당. 필요할 리가 없음!
# 2) encoder_outputs_list[[2]]: 마지막 타임스텝의 '은닉상태'(state_h).  
# 3) encoder_outputs_list[[3]]: 마지막 타임스텝의 '셀 상태'(state_c).  
# 우리는 '문맥'을 전달할 상태 두 가지만 필요함.  
encoder_states <- encoder_outputs_list[2:3] # [state_h, state_c](이것이 '문맥 벡터'임)

2) 디코더 정의

  • “문맥 벡터”와 “답변의 시작(t)”을 입력받아 “답변”을 출력하는 부분임.
# --- 디코더 정의: 문맥 벡터를 받아 답변을 생성 ---

# 1. 디코더의 "입력층"을 정의함(모델의 "입구 2").
# (답변 사전 크기인 num_decoder_tokens를 사용)  
decoder_inputs <- layer_input(shape = c(NA, num_decoder_tokens), name = "decoder_input")

# 2. 디코더의 "LSTM 레이어"를 '정의'함(가중치는 인코더와 공유하지 않음).
# (layer_lstm 논항 설명 참고)  
decoder_lstm_layer <- layer_lstm(  
  units = latent_dim, # 인코더와 동일한 기억 용량(차원 수가 맞아야 함)  
  return_sequences = TRUE, # (핵심!) 모든 타임스텝의 출력을 반환(답변 문장 전체)  
  return_state = TRUE, # 예측(inference) 시 현재 상태를 다음 스텝에 넘겨주기 위해 필요  
  name = "decoder_lstm"  
)

# 3. 디코더 입력과 "인코더의 상태"를 연결함.  
# ※※※ Seq2Seq의 가장 중요한 부분 ※※※  
# 디코더 LSTM을 호출할 때, initial_state 인자에 인코더가 반환한 '문맥 벡터(encoder_states)'를 주입함!  
# 이것이 인코더가 "질문"을 디코더에게 "전달"하는 방식.
decoder_outputs_list <- decoder_lstm_layer(  
  decoder_inputs,   
  initial_state = encoder_states # 인코더의 기억을 디코더의 초기 기억으로 설정!  
)

# 디코더 LSTM의 실제 출력(예측 시퀀스)은 리스트의 첫 번째 요소임  
decoder_outputs <- decoder_outputs_list[[1]]

# 4. 디코더의 "출력층(dense layer)"을 정의함.  
# LSTM의 출력(latent_dim)을 '답변사전 크기'로 변환하고,  
# 'softmax'를 사용해 각 글자의 확률을 계산함.  
decoder_dense_layer <- layer_dense(  
  units = num_decoder_tokens, # 답변사전 크기(모든 질문에 등장하는 고유 글자의 총 개수 -> 디코더 원핫 벡터의 차원 수)!
  activation = "softmax", # 각 글자의 확률 계산! 
  name = "decoder_output"  
)

# 5. 디코더 LSTM 출력과 출력층을 연결함.  
decoder_outputs <- decoder_dense_layer(decoder_outputs)

3) 훈련용 모델 조립 및 요약

  • keras_model() 함수로 2개의 입력과 1개의 출력을 가진 훈련용 모델을 완성함.
# --- 훈련용 모델(training model) 최종 조립 ---  
#  
# 훈련용 모델은 [인코더 입력(질문), 디코더 입력(답변시작)]을 받아서 [디코더 타겟(답변)]을 예측하도록 정의함.
# (keras_model 논항 설명 참고)  
training_model <- keras_model(  
  inputs = c(encoder_inputs, decoder_inputs), # 입력이 2개("입구 1", "입구 2")  
  outputs = decoder_outputs # 출력이 1개  
)

# 모델 구조 요약  
summary(training_model)

(6) 모델 컴파일 및 학습

  • 이제 ‘교사강요’ 방식으로 모델을 훈련시킴.
# --- 5. 모델 컴파일 및 학습 ---

# 1. 모델 컴파일  
training_model %>% compile(  
  optimizer = "rmsprop", # RNN/LSTM에 준수한 성능을 보이는 옵티마이저  
  loss = "categorical_crossentropy", # 다중 클래스(글자) 분류 문제이므로(가장 적절한 글자를 선택하는 거니까)   
  metrics = "accuracy" # 글자 단위 정확도 모니터링 
)

# 2. 모델 학습(fit)  
history <- training_model %>% fit(  
  # 입력(x): Keras 모델이 두 개의 입력을 받으므로 R 리스트로 전달  
  # 순서가 중요함: inputs = c(encoder_inputs, decoder_inputs) 순서와 일치해야 함  
  x = list(encoder_input_data, decoder_input_data),  
    
  # 출력(y): 디코더 타겟 데이터  
  y = decoder_target_data,  
    
  batch_size = 64, # 한 번에 64개의 Q&A 쌍을 학습  
  epochs = 20, # 실습을 위해 20으로 설정(성능을 위해선 100~200 이상 권장)  
  validation_split = 0.2 # 훈련 데이터의 20%를 검증용으로 사용  
)  

plot(history) # 훈련 과정 시각화 가능

(7) 예측(inference)을 위한 모델 재구성

  • 훈련과 예측은 방식이 다름. 예측 시에는 ‘교사강요’를 사용하지 않고, 한 글자씩 ’자기회귀’ 방식으로 생성해야 함.
  • 이를 위해 훈련된 가중치를 공유하는 예측 전용 모델 2개를 따로 만듦.

1) 예측용 인코더 모델

  • 역할: [질문] \(\rightarrow\) [문맥 벡터(states)].
  • 훈련 시 정의한 encoder_inputsencoder_states를 그대로 재사용.
# --- 6. 예측(Inference)을 위한 모델 재구성 ---

# 1. 예측용 인코더 모델  
# - 역할: [질문] -> [문맥 벡터(states)]  
# - 훈련 시 정의한 encoder_inputs와 encoder_states를 그대로 재사용
encoder_model <- keras_model(encoder_inputs, encoder_states)

summary(encoder_model)

2) 예측용 디코더 모델

  • 역할: [이전 글자 1개, 이전 상태] \(\rightarrow\) [다음 글자 확률, 다음 상태].
# 2. 예측용 디코더 모델

# 예측 시 디코더는 이전 스텝의 상태(state_h, state_c)를 입력으로 받아야 함.  
# 이를 위한 새로운 입력층을 정의함(상태 주입용 '손잡이').
decoder_state_input_h <- layer_input(shape = c(latent_dim), name = "inf_decoder_state_h")  
decoder_state_input_c <- layer_input(shape = c(latent_dim), name = "inf_decoder_state_c")  
decoder_states_inputs <- c(decoder_state_input_h, decoder_state_input_c)

# 주의: "훈련 시" 정의했던 'decoder_lstm_layer' 객체를 그대로 재사용함.  
# 이 레이어에는 이미 훈련된 가중치가 들어 있음.  
# 이번엔 initial_state로 encoder_states가 아닌, decoder_states_inputs(이전 스텝의 상태)를 받도록 연결함.  
decoder_outputs_list_inf <- decoder_lstm_layer(  
  decoder_inputs, # 이 입력은 이제 글자 1개짜리 시퀀스가 됨  
  initial_state = decoder_states_inputs 
  )

# 예측용 디코더의 출력도 2개임:  
decoder_outputs_inf <- decoder_outputs_list_inf[[1]] # (1) 현재 스텝의 출력(글자 확률)  
decoder_states_inf <- decoder_outputs_list_inf[2:3] # (2) 다음 스텝에 넘길 '새 상태'[new_h, new_c]

# 주의: "훈련 시" 정의했던 'decoder_dense_layer' 객체도 그대로 재사용함.  
decoder_outputs_inf <- decoder_dense_layer(decoder_outputs_inf)

# 최종 예측용 디코더 모델 조립  
decoder_model <- keras_model(  
  # 입력: 2종류(이전 글자 1개, 이전 상태[은닉상태, 셀 상태] 2개)  
  inputs = c(decoder_inputs, decoder_states_inputs),   
  # 출력: 2종류(다음 글자 예측 1개, 다음 상태[은닉상태, 셀 상태] 2개)  
  outputs = c(decoder_outputs_inf, decoder_states_inf)
  )

summary(decoder_model)

(8) 챗봇 응답 생성(디코딩) 함수 정의

  • 위에서 만든 encoder_modeldecoder_model을 사용하여, 질문이 들어왔을 때 답변을 한 글자씩 생성하는 R 함수를 정의함.
# --- 7. R에서 챗봇 응답 생성(디코딩) 함수 정의 ---

# input_text: 사용자가 입력한 질문([EX] "오늘 뭐해?")  
decode_sequence <- function(input_text) {  
    
  # 1. 입력 문장(질문)을 벡터화(원핫 벡터)  
  input_seq <- str_split(input_text, "")[[1]] # 질문을 글자 벡터로 쪼갬  
  # [1, 최대 질문 길이, 질문사전 크기] 형태의 0 배열 준비  
  input_data <- array(0L, dim = c(1, max_encoder_seq_length, num_encoder_tokens))  # 왜 샘플 수 = 1? 질문은 한 개니까.
    
  # 질문 텍스트를 원핫 벡터로 변환  
  for(t in 1:length(input_seq)) { # 글자 수만큼 반복  
    char <- input_seq[t] # t번째 글자 추출  
    # 사전에 없는 글자는 무시(0으로 남음)  
    if(char %in% names(input_token_index)) {  
      # [1번 샘플, t번째 타임스텝, char의 인덱스] 위치에 1을 할당(원핫 인코딩)
      input_data[1, t, input_token_index[[char]]] <- 1  
    } # if문 종료  
  } # for 루프 종료  
    
  # 2. 인코더 실행: encoder_model에 벡터화된 질문을 넣어 '문맥 벡터'를 받음  
  # states_value는 [state_h, state_c]를 담은 R 리스트가 됨  
  states_value <- encoder_model %>% 
    predict(input_data, verbose = 0)  
    
  # 3. 디코더 시작: '시작 토큰(t)'을 디코더의 첫 입력으로 준비  
  # 모양: [1(샘플), 1(타임스텝), 답변사전 크기]  
  target_seq_data <- array(0L, dim = c(1, 1, num_decoder_tokens))  
  # 시작 토큰('t')의 인덱스 위치에 1을 할당  
  target_seq_data[1, 1, target_token_index[["t"]]] <- 1  
    
  # 루프 중지 플래그  
  stop_condition <- FALSE  
  # 생성된 답변을 저장할 변수  
  decoded_sentence <- ""  
    
  # 4. 글자 생성 루프 시작(자기회귀 방식)  
  while(!stop_condition) {  
      
    # 5. decoder_model 실행(가장 핵심적인 예측 부분)  
    # 입력: (1) 이전 스텝의 글자(target_seq_data), (2) 이전 스텝의 상태(states_value)  
    output_list <- decoder_model %>% 
      predict(list(target_seq_data, states_value), verbose = 0)  
      
    # 출력:(1) 현재 글자 예측(output_tokens),(2) 다음 스텝에 넘길 '새 상태'  
    output_tokens <- output_list[[1]] #(1, 1, vocab_size) 형태의 확률 분포  
    states_value <- output_list[2:3] # [new_state_h, new_state_c](다음 루프에 사용됨)  
      
    # 6. 가장 확률이 높은 글자(인덱스)를 선택 
    # output_tokens[1, 1, ]는 현재 타임스텝의 글자별 확률 벡터임  
    sampled_token_index <- which.max(output_tokens[1, 1, ])  
    # 인덱스를 실제 글자로 변환  
    sampled_char <- reverse_target_char_index[[as.character(sampled_token_index)]]  
      
    # 생성된 글자를 문장에 추가  
    decoded_sentence <- str_c(decoded_sentence, sampled_char)  
      
    # 7. 종료조건 확인  
    # 예측된 글자가 '종료 토큰(n)'이거나, 최대 문장 길이를 넘으면 루프 종료  
    if(sampled_char == "n" || str_length(decoded_sentence) > max_decoder_seq_length) {  
      stop_condition <- TRUE # 루프 중지(종료조건을 충족하지 않았다면 다음 스텝으로 넘어감)
    }  
      
    # 8. 다음 스텝 준비  
    # 방금 예측한 글자 인덱스(sampled_token_index)를 다음 스텝의 '디코더 입력'으로 설정  
    # 디코더 입력 배열 초기화(0으로 구성된 3D 텐서): [1(샘플), 1(타임스텝), 답변사전 크기]
    target_seq_data <- array(0L, dim = c(1, 1, num_decoder_tokens)) 
    target_seq_data[1, 1, sampled_token_index] <- 1 # 예측된 글자 인덱스에 1 할당  
    # 'states_value'는 이미 위에서 업데이트되었으므로 그대로 다음 루프에 사용  
      
  } # while 루프 종료  
    
  # 'n' 토큰을 제거하고 반환  
  return(str_trim(str_replace(decoded_sentence, "n", "")))  
}

(9) 챗봇 테스트 실행

  • 훈련된 모델의 성능 테스트: 적은 데이터와 에포크로 훈련했기 때문에 답변이 어색하거나 엉뚱할 수 있지만, Seq2Seq 구조가 작동하는 것을 확인하는 것이 목적임.
# --- 8. R에서 챗봇 함수 실행 ---  

# 테스트할 질문 목록  
test_q <- c(  
  "오늘 뭐해?",   
  "심심하다",   
  "날씨 좋다",   
  "고마워",   
  "사랑해",  
  "배고파",  
  "넌 누구야?"  
)

# 각 질문에 대해 챗봇의 답변 생성  
for(q in test_q) {  
  # 학습된 모델로 답변 생성  
  response <- decode_sequence(q)  
  # 결과 출력  
  cat("질문:", q, "n")  
  cat("답변:", response, "n---n")  
} # for 루프 종료